/*E-Paper Analog Clock with ESP32 by mircemk, May 2025 */ #include "GxEPD2_BW.h" #include "Fonts/FreeSans9pt7b.h" #include "Fonts/FreeSansBold9pt7b.h" #include "WiFi.h" #include "esp_sntp.h" const char* TIMEZONE = "CET-1CEST,M3.5.0,M10.5.0/3"; const char* SSID = "******"; const char* PWD = "******"; // Pin definitions #define PWR 7 #define BUSY 48 #define RES 47 #define DC 46 #define CS 45 #define BUTTON_PIN 2 #define INVERT_BUTTON_PIN 1 // IO1 for inversion RTC_DATA_ATTR bool useRomanNumerals = false; // Store number style state in RTC memory RTC_DATA_ATTR bool invertedDisplay = false; // Store display inversion state // Helper function to convert number to Roman numeral const char* toRoman(int number) { static char roman[10]; const char* romanNumerals[] = {"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"}; if (number >= 1 && number <= 12) { strcpy(roman, romanNumerals[number - 1]); return roman; } return ""; } const char* DAYSTR[] = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" }; // W, H flipped due to setRotation(1) const int H = GxEPD2_420_GDEY042T81::HEIGHT; // Note: Using HEIGHT first const int W = GxEPD2_420_GDEY042T81::WIDTH; // Using WIDTH second const int CW = W / 2; // Center Width const int CH = H / 2; // Center Height const int R = min(W, H) / 2 - 10; // Radius with some margin const int BAR_WIDTH = 20; const int BAR_HEIGHT = GxEPD2_420_GDEY042T81::HEIGHT/1.3; // Half of display height const int BAR_MARGIN = 25; // Distance from clock edge const uint16_t WHITE = GxEPD_WHITE; const uint16_t BLACK = GxEPD_BLACK; RTC_DATA_ATTR uint16_t wakeups = 0; GxEPD2_BW epd(GxEPD2_420_GYE042A87(CS, DC, RES, BUSY)); uint16_t getFgColor() { return invertedDisplay ? WHITE : BLACK; } uint16_t getBgColor() { return invertedDisplay ? BLACK : WHITE; } void drawDisplayFrame() { // Outer frame epd.drawRect(0, 0, W, H, getFgColor()); // Inner frame (3 pixels gap) epd.drawRect(4, 4, W-8, H-8, getFgColor()); } void epdPower(int state) { pinMode(PWR, OUTPUT); digitalWrite(PWR, state); } void initDisplay() { bool initial = wakeups == 0; epd.init(115200, initial, 50, false); epd.setRotation(0); // Set rotation to 0 (90 degrees) epd.setTextSize(1); epd.setTextColor(getFgColor()); } void setTimezone() { setenv("TZ", TIMEZONE, 1); tzset(); } void syncTime() { if (wakeups % 50 == 0) { WiFi.begin(SSID, PWD); while (WiFi.status() != WL_CONNECTED) ; configTzTime(TIMEZONE, "pool.ntp.org"); } } void printAt(int16_t x, int16_t y, const char* text) { int16_t x1, y1; uint16_t w, h; epd.getTextBounds(text, x, y, &x1, &y1, &w, &h); epd.setCursor(x - w / 2, y + h / 2); epd.print(text); } void printfAt(int16_t x, int16_t y, const char* format, ...) { static char buff[64]; va_list args; va_start(args, format); vsnprintf(buff, 64, format, args); printAt(x, y, buff); } void polar2cart(float x, float y, float r, float alpha, int& cx, int& cy) { alpha = alpha * TWO_PI / 360; cx = int(x + r * sin(alpha)); cy = int(y - r * cos(alpha)); } void checkButton() { pinMode(BUTTON_PIN, INPUT_PULLUP); if (digitalRead(BUTTON_PIN) == LOW) { delay(50); // Debounce if (digitalRead(BUTTON_PIN) == LOW) { useRomanNumerals = !useRomanNumerals; redrawDisplay(); while(digitalRead(BUTTON_PIN) == LOW); // Wait for button release } } } void checkInversionButton() { pinMode(INVERT_BUTTON_PIN, INPUT_PULLUP); if (digitalRead(INVERT_BUTTON_PIN) == LOW) { delay(50); // Debounce if (digitalRead(INVERT_BUTTON_PIN) == LOW) { invertedDisplay = !invertedDisplay; redrawDisplay(); while(digitalRead(INVERT_BUTTON_PIN) == LOW); // Wait for button release } } } void redrawDisplay() { epd.setFullWindow(); epd.fillScreen(getBgColor()); drawDisplayFrame(); drawProgressBars(); drawClockFace(); drawClockHands(); drawDateDay(); epd.display(false); } void drawClockFace() { int cx, cy; epd.setFont(&FreeSansBold9pt7b); epd.setTextColor(getFgColor()); const int FRAME_THICKNESS = 1; // Outer frame thickness const int FRAME_GAP = 3; // Gap between outer and inner circles // Draw outer thick frame for(int i = 0; i < FRAME_THICKNESS; i++) { epd.drawCircle(CW, CH, R + i, getFgColor()); } // Draw inner circle after the gap epd.drawCircle(CW, CH, R - FRAME_GAP, getFgColor()); // Center dot epd.fillCircle(CW, CH, 8, getFgColor()); // Draw hour markers and numbers for (int h = 1; h <= 12; h++) { float alpha = 360.0 * h / 12; // Move numbers slightly inward to accommodate new frame polar2cart(CW, CH, R - 25, alpha, cx, cy); if (useRomanNumerals) { const char* romanNumeral = toRoman(h); printfAt(cx, cy, "%s", romanNumeral); } else { printfAt(cx, cy, "%d", h); } polar2cart(CW, CH, R - 45, alpha, cx, cy); epd.fillCircle(cx, cy, 3, getFgColor()); // Draw minute markers for (int m = 1; m <= 12 * 5; m++) { float alpha = 360.0 * m / (12 * 5); polar2cart(CW, CH, R - 45, alpha, cx, cy); epd.fillCircle(cx, cy, 2, getFgColor()); } } } void drawTriangle(float alpha, int width, int len) { int x0, y0, x1, y1, x2, y2; polar2cart(CW, CH, len, alpha, x2, y2); polar2cart(CW, CH, width, alpha - 90, x1, y1); polar2cart(CW, CH, width, alpha + 90, x0, y0); epd.drawTriangle(x0, y0, x1, y1, x2, y2, getFgColor()); } void drawClockHands() { struct tm t; getLocalTime(&t); // Calculate minute angle float alphaM = 360.0 * (t.tm_min / 60.0); // Calculate hour angle with smooth movement float hourAngle = (t.tm_hour % 12) * 30.0; float minuteContribution = (t.tm_min / 60.0) * 30.0; float alphaH = hourAngle + minuteContribution; // Draw the hands drawTriangle(alphaM, 8, R - 50); // Minute hand drawTriangle(alphaH, 8, R - 65); // Hour hand epd.fillCircle(CW, CH, 8, getFgColor()); // Center dot } void drawDateDay() { struct tm t; getLocalTime(&t); epd.setFont(&FreeSans9pt7b); epd.setTextColor(getFgColor()); printfAt(CW, CH+R/3, "%02d-%02d-%02d", t.tm_mday, t.tm_mon + 1, t.tm_year -100); printfAt(CW, CH-R/3, "%s", DAYSTR[t.tm_wday]); } void drawProgressBar(int x, int y, int width, int height, float percentage, const char* label) { // Draw outer rectangle epd.drawRect(x, y, width, height, getFgColor()); // Calculate inner area with margin int innerX = x + 3; int innerY = y + 3; int innerWidth = width - 6; int innerHeight = height - 6; // Calculate fill height int fillHeight = (int)(innerHeight * percentage); int fillTop = innerY + innerHeight - fillHeight; // First draw the filled portion epd.fillRect(innerX, fillTop, innerWidth, fillHeight, getFgColor()); // Now draw the ticks - they'll appear correctly in both filled and empty areas for(int i = 1; i < 4; i++) { int tickY = innerY + (innerHeight * i / 4); // For each pixel in the tick line for(int px = innerX; px < innerX + innerWidth; px++) { // If this pixel is in the filled area, use bg color, else use fg color uint16_t color = (tickY >= fillTop) ? getBgColor() : getFgColor(); epd.drawPixel(px, tickY, color); } } // Draw label above the bar epd.setFont(&FreeSans9pt7b); epd.setTextColor(getFgColor()); int16_t x1, y1; uint16_t w, h; epd.getTextBounds(label, 0, 0, &x1, &y1, &w, &h); epd.setCursor(x + (width - w)/2, y - 10); epd.print(label); } void drawProgressBars() { struct tm t; getLocalTime(&t); float hourProgress = (t.tm_min * 60.0f + t.tm_sec) / (60.0f * 60.0f); float dayProgress = (t.tm_hour * 3600.0f + t.tm_min * 60.0f + t.tm_sec) / (24.0f * 3600.0f); int leftX = BAR_MARGIN; int leftY = (H - BAR_HEIGHT)/2; int rightX = W - BAR_MARGIN - BAR_WIDTH; int rightY = (H - BAR_HEIGHT)/2; // Draw the progress bars drawProgressBar(leftX, leftY, BAR_WIDTH, BAR_HEIGHT, hourProgress, "HOUR"); drawProgressBar(rightX, rightY, BAR_WIDTH, BAR_HEIGHT, dayProgress, "DAY"); // Add elapsed time information below the bars epd.setFont(&FreeSans9pt7b); epd.setTextColor(getFgColor()); // Minutes elapsed char minuteStr[10]; sprintf(minuteStr, "%d m", t.tm_min); int16_t x1, y1; uint16_t w, h; epd.getTextBounds(minuteStr, 0, 0, &x1, &y1, &w, &h); epd.setCursor(leftX + (BAR_WIDTH - w)/2, leftY + BAR_HEIGHT + 20); epd.print(minuteStr); // Hours elapsed char hourStr[10]; sprintf(hourStr, "%d h", t.tm_hour); epd.getTextBounds(hourStr, 0, 0, &x1, &y1, &w, &h); epd.setCursor(rightX + (BAR_WIDTH - w)/2, rightY + BAR_HEIGHT + 20); epd.print(hourStr); } void drawClock(const void* pv) { static int lastMinute = -1; struct tm t; getLocalTime(&t); // Full refresh every minute if (lastMinute != t.tm_min || wakeups == 0) { epd.setFullWindow(); epd.fillScreen(getBgColor()); // Draw the display frame first drawDisplayFrame(); // Draw progress bars first (behind clock) drawProgressBars(); // Draw clock elements drawClockFace(); drawClockHands(); drawDateDay(); lastMinute = t.tm_min; } } void setup() { epdPower(HIGH); initDisplay(); setTimezone(); syncTime(); esp_sleep_wakeup_cause_t wakeup_reason = esp_sleep_get_wakeup_cause(); if (wakeup_reason == ESP_SLEEP_WAKEUP_EXT0) { checkButton(); } if (wakeup_reason == ESP_SLEEP_WAKEUP_EXT1) { uint64_t wakeup_pin_mask = esp_sleep_get_ext1_wakeup_status(); if (wakeup_pin_mask & (1ULL << INVERT_BUTTON_PIN)) { checkInversionButton(); } } drawClock(0); wakeups = (wakeups + 1) % 1000; epd.display(false); epd.hibernate(); // Enable wakeup from both buttons esp_sleep_enable_ext0_wakeup((gpio_num_t)BUTTON_PIN, LOW); esp_sleep_enable_ext1_wakeup((1ULL << INVERT_BUTTON_PIN), ESP_EXT1_WAKEUP_ANY_LOW); struct tm t; getLocalTime(&t); uint64_t sleepTime = (60 - t.tm_sec) * 1000000ULL; esp_sleep_enable_timer_wakeup(sleepTime); esp_deep_sleep_start(); } void loop() { }